feat(dashboard): render dashboard view with OpenTUI on the Bun binary#887
feat(dashboard): render dashboard view with OpenTUI on the Bun binary#887MathurAditya724 wants to merge 4 commits intofeat/init-wizard-ui-abstractionfrom
dashboard view with OpenTUI on the Bun binary#887Conversation
Replaces the framebuffer + grid composition logic in
`formatters/dashboard.ts` with an OpenTUI React app for the
Bun-compiled binary. The plain-text framebuffer is preserved
intact for the npm/Node distribution (where OpenTUI's Zig
bindings can't load), so existing tests + Node users see no
behavior change.
## What changed
- `src/lib/formatters/dashboard-app.tsx` (new) — React tree built
from OpenTUI primitives (`<box>` / `<text>`). Lays out the
dashboard header, then groups widgets by their grid `y` row
and renders each row as a `flexDirection="row"` flex container
with proportional widget widths (`(layout.w / 6) * termWidth`).
Each widget is a rounded bordered box with the title in the
border and content rendered via OpenTUI's flex layout — no more
manual framebuffer composition.
- `src/lib/formatters/dashboard-tui.ts` (new) — Bridge between
the React tree and a string. Uses OpenTUI's
`createTestRenderer()` (from `@opentui/core/testing`) to mount
the React tree off-screen, awaits a microtask for the
reconciler's async commit phase, calls `renderOnce()`, and
captures the rendered character grid via `captureCharFrame()`.
Trailing blank lines are stripped so the output doesn't pad
scrollback.
- `src/lib/formatters/dashboard.ts` — minor changes:
- `DashboardViewData` gains an optional `rendered?: string`
field (excluded from JSON output via `jsonExclude`) for
stashing the OpenTUI-rendered string.
- `renderContentLines()` is now exported so the OpenTUI app
can reuse the per-widget content helpers (sparklines,
big-number ASCII, table layout, markdown text) without
duplicating them. ANSI escape codes from these helpers are
stripped by the OpenTUI app before rendering — colors get
reapplied via OpenTUI's `fg` prop at the row level.
- `createDashboardViewRenderer` checks `data.rendered` first
and uses it directly; otherwise falls through to the
original `formatDashboardWithData` framebuffer path.
- `src/commands/dashboard/view.ts` — pre-renders the dashboard
with OpenTUI inside the async `func()` (before yielding the
`CommandOutput`) so the synchronous `HumanRenderer.render()`
can just return the pre-baked string. Lazy-imports
`dashboard-tui.js` via dynamic `import()` so its module-level
`with { type: "file" }` resolution + heavy OpenTUI deps don't
load when this command isn't being run — important because
tests walk the Stricli route map (via `app.ts`) and would
otherwise eagerly evaluate the OpenTUI side effects.
Falls back to the plain-text formatter on import or render
failure (Node distribution).
- `script/build.ts` + `script/bundle.ts` — extended sidecar
cleanup to include `dashboard-app.tsx` alongside the existing
`opentui-app.tsx`. The text-import-plugin copies both files
into the output directory at build time; both are embedded
into the Bun binary and unused on Node, so the local
`dist*/` cleanup just keeps things tidy.
## Trade-offs
The OpenTUI `<text>` primitive doesn't honor embedded ANSI
escape codes — its content string is plain text. The plain-text
renderer's per-row mixed coloring (e.g. cyan label + magenta
sparkline + bold value on the same line) collapses to one
dominant color per row in the OpenTUI version. Tables get bold
headers + muted separators + plain body rows; sparkline rows
get accent purple; big-number rows get green. Loses some
granularity on tables/sparklines but the layout — which is what
OpenTUI is buying us — comes out clean.
The grid composition is approximate. The plain-text framebuffer
does true 2D composition where a tall widget can span multiple
row groups. The OpenTUI app groups widgets by their starting
`y` and renders each row group flush — widgets with non-uniform
`h` within a row render at their own height but don't overlap
the next row. Most dashboards we've seen are uniform-height per
row, so the approximation lands clean.
## Verification
- `bun run typecheck` (clean)
- `bun x ultracite check` (1 pre-existing warning, no new ones)
- `bun test --isolate test/lib/init/ test/lib/formatters/dashboard*.test.ts test/commands/dashboard` (458 pass)
- `SENTRY_CLIENT_ID=test bun run build` (binary 118.29 MB,
unchanged from before)
- `SENTRY_CLIENT_ID=test bun run bundle` (npm 3.21 MB,
unchanged)
- `./dist-bin/sentry-linux-x64 dashboard view --help` (renders
cleanly)
- Visual smoke test rendering a 3-widget dashboard confirms
bordered widget boxes, vertical bar chart, big-number, and
table all lay out correctly with OpenTUI's flex engine.
## Tests added
- `test/lib/formatters/dashboard-tui.test.ts` — 10 coarse
end-to-end tests asserting the renderer produces a
non-empty string with the dashboard title, period badge,
environment badge, widget titles, box-drawing characters,
trailing-blank-line trimming, and graceful handling of
empty widget lists, orphan widgets, and error widgets.
|
… runners Two CI-only failures from PR #887: 1. Biome formatter wanted the sidecar-cleanup for-loop on a single line. Auto-fixed via `bun x biome format --write script/bundle.ts`. 2. Tests fail in CI with an empty captured frame because React's reconciler commit + OpenTUI's layout pass don't fully complete after one `setTimeout(0)` + `renderOnce()` on slower CI runners. Render twice with a microtask wait between — the first pass flushes pending layout effects, the second captures the laid-out frame. Locally a single pass works because event-loop turns are faster.
Codecov Results 📊✅ 6382 passed | Total: 6382 | Pass Rate: 100% | Execution Time: 0ms 📊 Comparison with Base Branch
All tests are passing successfully. ❌ Patch coverage is 49.57%. Project has 13591 uncovered lines. Files with missing lines (5)
Coverage diff@@ Coverage Diff @@
## main #PR +/-##
==========================================
- Coverage 75.89% 75.54% -0.35%
==========================================
Files 294 303 +9
Lines 54604 55569 +965
Branches 0 0 —
==========================================
+ Hits 41440 41978 +538
- Misses 13164 13591 +427
- Partials 0 0 —Generated by Codecov Action |
Converts the static OpenTUI dashboard renderer into a live
interactive TUI on the Bun binary. The npm/Node distribution
falls back to the existing plain-text framebuffer, unchanged.
Keybindings:
- Tab / Shift+Tab / arrows Cycle widget focus
- Enter Drill into focused widget
- Esc Back out one layer (drilldown → help → quit)
- t Cycle time period (1h → 24h → 7d → 30d → 90d)
- r Refresh now
- R Toggle auto-refresh
- o Open dashboard in browser
- ? Toggle keybindings overlay
- q / Ctrl+C Quit
Implementation:
- `dashboard-store.ts` — external state store with action
dispatcher slot. Owns focus index, drilldown / help overlay
flags, current period, auto-refresh enabled, fetching state,
fetch error. Pure mutators with idempotency invariants.
- `dashboard-runtime.ts` — mounts a long-lived
`createCliRenderer({ exitOnCtrlC: false, screenMode:
"alternate-screen" })`. Services dispatched actions:
`refresh` and `cycle-period` re-fetch via the caller-supplied
`fetch` callback; `toggle-auto-refresh` flips the timer;
`open-in-browser` calls `openBrowser` directly (NOT
`openInBrowser`, which would write "Opening in browser…" to
stdout and corrupt the alternate-screen TUI); `quit` resolves
the runtime promise so the caller returns to the shell.
Cleanup order: detach dispatcher → stop timer → unmount React
tree → destroy renderer (releases alt-screen + raw mode last).
- `dashboard-app.tsx` — App now subscribes via
`useSyncExternalStore`, has a `useKeyboard` hook that
delegates to extracted top-level handler functions
(`handleKey`, `handleEscape`, `handleHelpKey`,
`handleDrilldownKey`, `handleGridKey`). Renders a Drilldown
view that takes over the screen for the focused widget, a
HelpOverlay that lists keybindings, and a StatusBar pinned
to the bottom showing the current shortcut hints.
- `dashboard-tui.ts` (static path) — unchanged behavior;
constructs a default-state store and passes it to the same
App component the interactive runtime uses. Existing 10
static-render tests still pass.
- `view.ts` — adds `isInteractiveContext()` (TTY both ends +
non-JSON + Bun runtime) and `tryRunInteractive()` which
lazy-imports `dashboard-runtime.js` (matches the existing
`tryPreRenderTui` lazy-import pattern). Falls through to the
existing static path on import failure or non-interactive
context. The `--refresh N` flag now means "start interactive
with auto-refresh ON at N seconds" rather than a static
polling loop; legacy non-interactive consumers (CI / piped /
JSON) still get the polling loop.
Bun-binary only — same gating as the static path. Non-Bun
runtimes externalize OpenTUI and the lazy import resolves to
"Cannot find module", caught and falling through to plain
text.
Two new test files for the interactive dashboard:
- `dashboard-store.test.ts` (19 tests) — initial state,
cycleFocus wraparound (forward from -1, backward from -1,
wrap at ends), setFocusedWidget clamping (-1 allowed,
out-of-range clamped to nearest valid), toggleDrilldown
no-op when no focus, exitDrilldown idempotency, data
mutations clearing fetching/fetchError, action dispatcher
registration / clearing, subscriber notification on real
changes only.
- `dashboard-app.handlers.test.ts` (25 tests) — keyboard
dispatch state machine: universal keys (Ctrl+C, Esc with
staged dismissal), grid mode (Tab / Shift+Tab / backtab /
arrows / Enter / ? / q / t / r / Shift+R / R-via-sequence /
o / unknown), help overlay mode (? toggles, q quits, other
keys swallowed), drilldown mode (Enter exits, q quits,
navigation swallowed).
`handleKey` and friends are exported from `dashboard-app.tsx`
specifically so unit tests can exercise them as pure functions
— driving real keystrokes through `useKeyboard` requires a
raw-mode TTY which `bun test` can't reliably allocate in a
sandboxed PTY.
A `setup()` helper pre-mutates the store to match the test's
declared overlay state and reads the live snapshot on each
`fire()` call, so individual tests don't need to thread snapshot
mutations between events — matches the real `useKeyboard` hook
in App which closes over the latest store snapshot via
`useSyncExternalStore`.
Note on test isolation: when run together via `bun test path1
path2`, the handlers test fails to import `handleKey` from the
.tsx file because `dashboard-tui.test.ts` triggers a `with {
type: "file" }` resource load of the same path which collides
with module resolution. The `bun run test:unit` script uses
`--isolate` (separate module graph per file), where all 6382
tests pass — same isolation pattern the wizard's OpenTUI tests
already rely on.
| const idx = PERIOD_CYCLE.indexOf(current); | ||
| const nextIdx = (idx + 1) % PERIOD_CYCLE.length; | ||
| const next = PERIOD_CYCLE[nextIdx] ?? PERIOD_CYCLE[0] ?? "24h"; |
There was a problem hiding this comment.
Bug: Period cycling logic incorrectly handles periods not in the PERIOD_CYCLE list, causing it to reset to "1h" on the first cycle attempt.
Severity: MEDIUM
Suggested Fix
To fix this, the period cycling logic should handle cases where the current period is not found in the PERIOD_CYCLE array. One approach is to find the closest period in the array and start cycling from there, or to default to a sensible starting point like "24h" instead of always jumping to "1h".
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: src/lib/formatters/dashboard-runtime.ts#L216-L218
Potential issue: The `cycle-period` action in `dashboard-runtime.ts` uses
`PERIOD_CYCLE.indexOf(currentPeriod)` to determine the next period when a user cycles
through time ranges. If the `currentPeriod` is a valid but non-standard value (e.g.,
`"2w"` or `"14d"`, which can be set via the CLI or loaded from a dashboard's saved
state), `indexOf` returns -1. The subsequent calculation `(-1 + 1) % length` always
results in 0, causing the period to unexpectedly jump to the first element of
`PERIOD_CYCLE` (`"1h"`) instead of cycling logically.
Did we get this right? 👍 / 👎 to inform future reviews.
|
Created this for fun, closing this now cause it doesn't add value |
Summary
Replaces the framebuffer + grid composition logic in
dashboard viewwith an OpenTUI React app for the Bun-compiled binary. The plain-text framebuffer (1868 lines informatters/dashboard.ts) is preserved intact for the npm/Node distribution where OpenTUI's Zig bindings can't load — Node users see no behavior change. Bun-binary users get a sexier rendered dashboard with proper flex layout, bordered widgets, and consistent styling.What it looks like
What changed
src/lib/formatters/dashboard-app.tsx(new) — React tree built from OpenTUI primitives. Lays out the dashboard header, then groups widgets by their gridyrow and renders each row as aflexDirection="row"flex container with proportional widget widths ((layout.w / 6) * termWidth). Each widget is a rounded bordered box with the title in the border.src/lib/formatters/dashboard-tui.ts(new) — Bridge that mounts the React tree off-screen viacreateTestRenderer()(from@opentui/core/testing), awaits a microtask for React's async commit, callsrenderOnce(), and captures the rendered character grid viacaptureCharFrame(). Trailing blank lines are stripped.src/lib/formatters/dashboard.ts—DashboardViewDatagains optionalrendered?: string(excluded from JSON viajsonExclude).renderContentLines()is exported so the OpenTUI app reuses the existing per-widget content helpers without duplication.createDashboardViewRendererusesdata.renderedif present, else falls through to the legacy framebuffer.src/commands/dashboard/view.ts— pre-renders with OpenTUI inside the asyncfunc()before yieldingCommandOutputso the synchronousHumanRenderer.render()returns the pre-baked string. Lazy-importsdashboard-tui.jsvia dynamicimport()to keep itswith { type: "file" }resolution + heavy OpenTUI deps out of test paths that walk the Stricli route map.script/build.ts+script/bundle.ts— extended sidecar cleanup to includedashboard-app.tsxalongside the existingopentui-app.tsx.Trade-offs
<text>strips ANSI from its content. The plain-text renderer's per-segment row coloring (cyan label + magenta sparkline + bold value on the same line) collapses to one dominant color per row. Tables: bold headers + muted separators. Sparklines: accent purple. Big numbers: green. Loses some granularity but layout (the win) is solid.yand renders each row group flush — widgets with non-uniformhwithin a row don't overlap the next row. Most dashboards are uniform-height per row, so it lands clean.Bun-binary only (same gating as the wizard)
@opentui/coreships native Zig bindings that don't load on Node. The npm distribution externalizes OpenTUI from its bundle entirely, and the lazyimport("./dashboard-tui.js")will throw "Cannot find module" at runtime there. The view command catches that and falls back toformatDashboardWithData(the original framebuffer renderer). Node users get exactly the output they get today.Verification
bun run typecheck(clean)bun x ultracite check(1 pre-existing warning, no new ones)bun test --isolate test/lib/init/ test/lib/formatters/dashboard*.test.ts test/commands/dashboard— 458 pass (includes 10 new tests for the OpenTUI bridge)SENTRY_CLIENT_ID=test bun run build(binary 118.29 MB, unchanged)SENTRY_CLIENT_ID=test bun run bundle(npm 3.21 MB, unchanged)./dist-bin/sentry-linux-x64 dashboard view --help(renders cleanly)Tests added
test/lib/formatters/dashboard-tui.test.ts— 10 coarse end-to-end tests asserting the renderer produces a non-empty string containing dashboard title, period badge, environment badge, widget titles, box-drawing characters; verifies trailing-blank-line trimming and graceful handling of empty widget lists, orphan widgets (no layout), and error widgets.